250618 动态地形与粒子水?小鳄鱼爱洗澡的实现方法!
怎么通篇都是后处理
预览

500粒子90帧左右,600往上帧率骤降,700左右为游戏赋予24帧电影感。
水流
思路
粒子水是一种很经典的流体模拟方式。这个“粒子”指的不一定是ParticleSystem,也可以是一堆普普通通的挂载了CircleCollider2D与Rigidbody2D的圆。使用两个摄像机:由副摄像机渲染这些粒子,将渲染结果放到一张RenderTexture(简称RT)上;主摄像机不渲染这些粒子,而是渲染这张经过后处理的RT。
油管上这两个视频讲得很清楚:
https://www.youtube.com/watch?v=_8v4DRhHu2g
https://www.youtube.com/watch?v=3r6o8CfRlA4
这里稍作整理。
实现
- 由于我们使用19201080的分辨率,这里新建一张16: 9的RT。为了起到模糊的作用,这张贴图的大小就设置为320180。
- 新建一个摄像机,按如下方式设置:
- 正交投影,Size为5.4。
- CullingMask设置为Water,这是专门拍摄水粒子的摄像机。
- OutputTexture设置为刚刚新建的RenderTexture。
- 主摄像机的CullingMask去掉Water。
- 制作一个粒子:
- 新建空物体,Tag和Layer设置为Water(前者在表现上其实并非必要)。
- 挂载CircleCollider2D,Radius设置为0.1。
- 挂载Rigidbody2D,勾选FreezeRotation,新建一个PhysicsMaterial,Bounciness设置为0.5(这个还是根据表现自己调)。
- 挂载一个SpriteRenderer,Sprite选择一个Circle,材质要一个从中心到边缘逐渐变淡的。可以选择Particle的材质,但有点太小了,需要自己做一些调整。我的做法是自己写一个。
至于怎么让这个粒子出现在场景中就省略了
- 创建一个shader,核心在于片元着色器:
// WaterParticle.shader float4 frag (v2f i) : SV_Target { float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv); col.a = 1 - length(i.uv - float2(.5, .5)) * 2; // 朴素的衰减线条 return col; }- 用它创建一个材质,供粒子使用。
- 此时我们应该可以看到RT的变化,接下来的shader是处理这张RT用的:
- 暴露的参数:
Properties { _MainTex ("Texture", 2D) = "white" {} _WaterTex ("Water Texture", 2D) = "white" {} _WaterThreshold ("Water Threshold", Range(0.0, 1.0)) = 0.5 _Scale ("Scale", float) = 50 } - 核心的片元着色器: float4 frag (v2f i) : SV_Target { float4 col = SAMPLE_TEXTURE2D(_WaterTex, sampler_WaterTex, i.uv); // 采样RT col.a = step(_WaterThreshold, col.a); // 仅选取透明度高于阈值的部分 col.rgb = SAMPLE_TEXTURE2D(_MainTex, sampler_WaterTex, (i.uv * _Scale + float2(0, _Time.y)) % 1.0).rgb; // 随时间变化的采样,体现流动感 return col; } - 材质中的WaterTex就是一开始创建的RT。
- 暴露的参数:
- 创建一个挂载了SpriteRenderer的物体。由于刚刚的shader采样的水流纹理是_MainTex,属性中的第一张纹理,而第一张纹理对应TEXCOORD0语义,这意味着Unity会把SpriteRenderer上的Sprite作为_MainTex传进来,所以就拿水流贴图作为这个Sprite吧。调整物体大小使之刚好铺满摄像机范围。
效果如下:

地形
思路
注意到SpriteRenderer与PolygonCollider2D先后挂载到同一物体上时,碰撞箱范围会随图片透明度改变。这个碰撞箱具体是什么范围,可以在PolygonCollider2D的Points属性里看到。Sprite其实可以获取这些顶点信息(详见Sprite.GetPhysicsShape),从而动态调整Points属性。
参考这个视频,一开始的思路是,根据鼠标位置改变图片的透明度,从而影响SpriteRenderer同物体上的PolygonCollider2D。尝试后发现达到了惊人的个位数帧率,并且性能瓶颈在动态改变图片像素上,无法优化,遗憾离场。
还有一种思路:将物理与图像拆开。物理上,调用NetTopologySuite(简称NTS)计算顶点信息,使用EdgeCollider2D以便调整;图像上,沿用粒子水的后处理思路,记录鼠标轨迹,影响背景图片的透明度。
实现
物理
- Unity中不能直接用NuGet包管理器,需要把会用到的dll放进Plugins文件夹。NTS的依赖项不少,看起来像是为了性能写了一大堆unsafe代码:

- 在地形图片下面新建一堆挂载了EdgeCollider2D的物体,勾画出待挖掘地形的轮廓。为了避免NTS无法处理的状况,每个碰撞体最好:
- 无限接近于封闭图形
- 自身没有相交
- 是凸多边形
这图其实还是草率了点

- 创建一个C#脚本,要点写在注释里面:
// Pointer.cs
using NetTopologySuite.Geometries;
using System.Collections.Generic;
using UnityEngine;
public class Pointer : MonoBehaviour
{
[SerializeField] private Transform terrain; // EdgeCollider2D的父物体
[SerializeField] private float radius = 1; // 指定挖掘范围
private Vector2 GetCursorPos() => Camera.main.ScreenToWorldPoint(Input.mousePosition);
// 古法画圆
private Polygon CreateSphere(Vector2 center) =>
new(new LinearRing(new Coordinate[]
{
new (center.x + radius, center.y),
new (center.x + radius * .707, center.y - radius * .707),
new (center.x, center.y - radius),
new (center.x - radius * .707, center.y - radius * .707),
new (center.x - radius, center.y),
new (center.x - radius * .707, center.y + radius * .707),
new (center.x, center.y + radius),
new (center.x + radius * .707, center.y + radius * .707),
new (center.x + radius, center.y),
}));
// 根据一组顶点设置一个EdgeCollider2D
private void SetEdge(EdgeCollider2D edge, Geometry geometry)
{
List<Vector2> list = new();
foreach (var point in geometry.Coordinates) list.Add(new((float)point.X, (float)point.Y));
edge.SetPoints(list);
edge.transform.parent = terrain;
}
private void Update()
{
if (!Input.GetMouseButton(0)) return;
// 这里有一大堆有待优化的GC,但帧率大幅下降的时候,Profiler里面基本只能看到物理的开销
transform.position = GetCursorPos(); // 跟随鼠标
var pointer = CreateSphere(transform.position);
var edges = terrain.GetComponentsInChildren<EdgeCollider2D>();
for (int i = 0; i < edges.Length; i++)
{
// 将每一个EdgeCollider转化为NTS的几何信息
var points = edges[i].points;
var coordinates = new Coordinate[points.Length + 1];
for (int j = 0; j < points.Length; j++)
coordinates[j] = new(points[j].x, points[j].y);
coordinates[points.Length] = new(points[0].x, points[0].y);
var poly = new Polygon(new LinearRing(coordinates));
// 逐碰撞体求待挖掘范围与鼠标范围的差集
if (poly.IsValid && poly.Overlaps(pointer))
switch (poly.Difference(pointer))
{
// 鼠标范围把一个碰撞体分成了多块
case MultiPolygon multi:
bool first = true;
foreach (var geo in multi)
if (geo.IsValid)
if (first)
{
// 第一块直接修改原碰撞体
SetEdge(edges[i], geo);
first = false;
}
// 之后的则添加新碰撞体
else SetEdge(new GameObject().AddComponent<EdgeCollider2D>(), geo);
break;
// 原碰撞体并没有被分成多块
case Polygon single: if (single.IsValid) SetEdge(edges[i], single); break;
}
}
}
// 预览挖掘范围
private void OnDrawGizmos()
{
Gizmos.color = Color.blue;
Gizmos.DrawWireSphere(transform.position, radius);
}
}
- 创建一个空物体,叫它Pointer好了:
- Layer改为Pointer。
- 挂载刚刚写的C#脚本。
图像
- 新建两张1920*1080的RT,一张显示鼠标(PointerTex),一张作为遮罩(MaskTex)。
- 新建一个CullingMask为Pointer的摄像机,OutputTexture为PointerTex。
- 主摄像机的CullingMask去掉Pointer。
- 新建一个用于blit的shader,属性有_MainTex与_Shape两张纹理,片元着色器如下:
// Mask.shader float4 frag (v2f i) : SV_Target { float alpha1 = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv).a; float alpha2 = SAMPLE_TEXTURE2D(_Shape, sampler_MainTex, i.uv).a; return float4(1, 1, 1, max(alpha1, alpha2)); // 保留透明度高的部分 }- 材质中的_Shape是MaskTex。
- Pointer上挂载一个SpriteRenderer,随便搞张圆形图片作为Sprite,大小与挖掘范围一致。
- C#脚本也有相应修改:
- 添加几个参数
[SerializeField] private RenderTexture shape; // PointerTex [SerializeField] private RenderTexture mask; // MaskTex [SerializeField] private Material mat; // 基于刚刚创建的shader的材质 - 每一帧鼠标左键按下时:
// 由于材质也用了MaskTex,这里需要一给临时RT作为缓冲 var temp = RenderTexture.GetTemporary(mask.width, mask.height); Graphics.Blit(shape, temp, mat); Graphics.Blit(temp, mask); temp.Release(); // 用完记得释放,不然小心爆显存
- 添加几个参数
- 新建一个用于显示待挖掘地形的shader,属性有_MainTex与_Shape两张纹理,片元着色器如下:
float4 frag (v2f i) : SV_Target { float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv); float alpha = SAMPLE_TEXTURE2D(_Shape, sampler_MainTex, i.uv).a; col.a *= 1.0 - alpha; // 被挖过的地形不予显示 return col; }- 材质中的_Shape是MaskTex。
- 将这个材质挂到待挖掘地形的SpriteRenderer上,大功告成!
优化
粒子看着太粒子了
圆形的粒子太容易被看穿,一旦有粒子从一大片粒子中脱出,或者有即将脱出的迹象,这个圆形就会非常显眼。
按照策划同学的建议,把粒子的图片从1: 1的正圆,拉伸成了1.5: 1的椭圆。一开始我以为无济于事,但是试着动了一下,奇迹发生了,它变得像真正的河水一样奔涌起伏。

河水有点太细小了
这个水流对于小鳄鱼爱洗澡已经够用了,但是对于河流却过于细小。
此外,部分粒子还可能被卡到本来不该出现的位置,出现穿帮,严重影响观感;而这很难通过物理修改,毕竟物理模拟已经全部交给Unity了。
还是按照策划同学的建议,试着将图片放大为原来的两倍,又不改变碰撞体大小。由于河床上层并非不透明,泥土之下难免暗流涌动。
好在我们之前记录过鼠标轨迹,只需要将记录鼠标轨迹的RT作为遮罩,加入到粒子水的后处理中。
- 参数新增一张纹理:
_Shape ("Shape", 2D) = "white" {} - 片元着色器新增一个影响透明度的因素:
col.a *= SAMPLE_TEXTURE2D(_Shape, sampler_Shape, i.uv).a;
河道可以顺利填满,被卡出去的粒子也无法显示,再也不用再担心穿帮问题了!
(预览见最上方)
剩下的问题
- 粒子可能全都堵在漏斗口上不掉落,这个可能可以通过设置粒子存活时间解决。
- NTS可能认为有些形状无法处理,导致会有一些莫名其妙的碰撞体残留,这个只能继续摸索NTS了。
参考
https://www.youtube.com/watch?v=_8v4DRhHu2g